Skip to content

feature(middleware): Composable fetch middleware for auth and cross‑cutting concerns #485

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Aug 18, 2025

Conversation

m-paternostro
Copy link
Contributor

@m-paternostro m-paternostro commented May 13, 2025

This PR adds a small, composable middleware layer around generic fetch that makes it straightforward to layer authentication and operational behaviors. It includes a built‑in withOAuth middleware that performs the full OAuth flow on 401s, a withLogging middleware for request/response logging, and helpers to compose and author custom middleware.

While not changing the transports’ existing behavior immediately, this provides a simple pattern you can opt into when you want fetch-level extensibility. Notably, it is now trivial to provide custom authentication by writing your own middleware (e.g., withMyAuth).

Motivation and Context

Embedding apps often need to:

  • Integrate with existing auth (platform tokens, ambient creds, preexisting identity systems)
  • Add cross‑cutting concerns (logging, caching, metrics, tracing)

Previously, adapting auth typically required transport subclassing or invasive overrides. By introducing fetch middleware:

  • Auth concerns can be handled orthogonally to transports
  • Custom auth is a small, focused function rather than a reimplementation of transport logic
  • Cross‑cutting behaviors compose cleanly and predictably

Transports such as SSEClientTransport and StreamableHTTPClientTransport continue to have their own built‑in OAuth handling; at the moment this middleware is an optional, general‑purpose facility for cases where you’re working directly with fetch or want an explicit pipeline around it.

Custom Authentication

It is now fairly simple to implement custom auth as a small, testable function without touching transports:

const withMyAuth = createMiddleware(async (next, input, init) => {
  const headers = new Headers(init?.headers);
  headers.set("Authorization", `Bearer ${getMyPlatformToken()}`);
  let response = await next(input, { ...init, headers });

  if (response.status === 401) {
    await myReauthFlow(); // app-specific
    const refreshed = new Headers(init?.headers);
    refreshed.set("Authorization", `Bearer ${getMyPlatformToken()}`);
    response = await next(input, { ...init, headers: refreshed });
  }

  return response;
});

and then compose it with other behaviors

const enhancedFetch = applyMiddlewares(
  withMyAuth,
  withLogging({ statusLevel: 400 })
)(fetch);

How Has This Been Tested?

Added unit tests for the middleware pipeline, including:

  • Token header injection and single retry on 401
  • Redirect propagation via UnauthorizedError
  • baseUrl handling vs. URL‑derived origin
  • Logging behavior with thresholds and header inclusion

Breaking Changes

None. Everything is opt‑in and additive.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@m-paternostro m-paternostro force-pushed the mp/delegatedauth branch 5 times, most recently from 3352fce to 96f19fc Compare May 15, 2025 13:03
@m-paternostro m-paternostro changed the title feature(auth): OAuthClientProvider.delegateAuthorization feature(auth): Allow delegating OAuth authorization to existing app-level implementations May 15, 2025
@m-paternostro m-paternostro force-pushed the mp/delegatedauth branch 4 times, most recently from b0e2654 to 1bf3a74 Compare May 21, 2025 03:02
@ihrpr ihrpr added this to the HPR milestone May 21, 2025
@m-paternostro m-paternostro force-pushed the mp/delegatedauth branch 4 times, most recently from 7758221 to 02f8659 Compare May 27, 2025 14:06
@m-paternostro m-paternostro force-pushed the mp/delegatedauth branch 2 times, most recently from 59b8e7f to e544126 Compare May 30, 2025 11:54
@m-paternostro
Copy link
Contributor Author

Hi @ihrpr ,

Sorry for the direct tag. I really appreciate that this is already on the HPR list, and I completely understand you have a lot on your plate.

This is just a gentle nudge on the PR - a decision here would really help me move forward on my side, especially if the change is accepted.

Thanks again for all the amazing work on the SDK! It’s been a real pleasure working with it over the past three months ;-)

@m-paternostro m-paternostro force-pushed the mp/delegatedauth branch 2 times, most recently from b8b1a68 to 96d2b31 Compare June 10, 2025 19:29
@m-paternostro
Copy link
Contributor Author

One additional detail worth mentioning: the OAuth implementation used by my company (and others as well 😉) includes JWT tokens in the final authentication response. These tokens encode valuable metadata such as user identity, organization context, and more.

This is yet another reason to allow client implementations to fully control the authentication flow - they may want to extract and act on this information in ways that are specific to their environment.

@m-paternostro m-paternostro force-pushed the mp/delegatedauth branch 7 times, most recently from aa7a7b1 to 2be7d47 Compare June 20, 2025 12:19
@m-paternostro
Copy link
Contributor Author

👋 Hello,

I just wanted to follow up on this PR. I’ve been keeping it up to date over the past month, including adapting to changes like the recent addition of protected resource support (RFC 8707), which this PR now explicitly handles.

I really appreciate that it was marked as an HPR and I took that as a sign that it might be reviewed soon. I also sent a (hopefully gentle) nudge to @ihrpr at the time, just to make sure it was on the radar.

My main reason for commenting now is to ask for a bit of clarity: I’m more than happy to continue monitoring and updating this PR for as long as there’s a reasonable chance it might be reviewed and potentially merged. I sincerely believe it improves the SDK’s OAuth2 support and brings tangible value to the community.

That said, I totally understand if this PR isn’t likely to be accepted either for technical or strategic reasons. However, in this case, I’d rather step back than keep chasing changes unnecessarily.

Regardless, thanks again for all the work you do on this project!

@m-paternostro m-paternostro requested review from ochafik and ihrpr July 18, 2025 17:02
@pcarleton
Copy link
Member

Hey @m-paternostro sorry for the delay here, this is on my list, just didn't get to it this week.

@pcarleton
Copy link
Member

hey @m-paternostro thanks for the patience here. I had a long chat with some other folks (@ihrpr , @dsp-ant, @ochafik ) and we're thinking that an even higher level approach would be better here, and I wanted to see if it'd work for you and if you'd be up for adjusting this PR to tackle it.

It'd look something like this:

  // Simple auth wrapper type
  type FetchWrapper = (fetch: FetchLike) => FetchLike;

  // The default implementation
  const withOAuth = (provider: OAuthProvider): FetchWrapper =>
    (fetch) => async (input, init) => {
      const headers = new Headers(init?.headers);
      const tokens = await provider.tokens();
      if (tokens) {
        headers.set('Authorization', `Bearer ${tokens.access_token}`);
      }

      const response = await fetch(input, { ...init, headers });
      if (response.status === 401) {
          const resourceMetadataUrl = extractResourceMetadataUrl(response);

          const result = await auth(provider, { serverUrl: this._url, resourceMetadataUrl, fetchFn: fetch });
          if (result !== "AUTHORIZED") {
            throw new UnauthorizedError();
          }

          const tokens = await provider.tokens();
          if (tokens) {
            headers.set('Authorization', `Bearer ${tokens.access_token}`);
          }

          return await fetch(input, { ...init, headers });
      }

      return response;
    };


  // Adding a custom wrapper:
  const transport = new StreamableHttpClientTransport(url, {
    fetch: withCustomOAuth(oauthProvider)(withLogging(fetch))
  });

The idea is then we could support any type of custom auth there, or other request/response munging.

Let me know what you think.

@m-paternostro m-paternostro requested a review from a team as a code owner August 5, 2025 10:46
@m-paternostro
Copy link
Contributor Author

m-paternostro commented Aug 5, 2025

Hey @pcarleton - sorry for the delay (was out on PTO as mentioned 😊)

I really like the direction this design is heading. I took a pass at implementing the default OAuth and logging wrappers you suggested. And as you were likely signaling, the logic now feels simple and flexible enough that writing custom wrappers is straightforward - no need for a dedicated CustomAuth type or withCustomOAuth.

Btw, for now I kept everything in a single file under shared - we should probably split it, placing the withOAuth in client.

Let me know your thoughts!

@m-paternostro m-paternostro force-pushed the mp/delegatedauth branch 3 times, most recently from 28d184f to f906891 Compare August 7, 2025 10:31
@dsp-ant
Copy link
Member

dsp-ant commented Aug 8, 2025

@m-paternostro Thank you. This is really good and nearly where we want it.

I took the liberty to refactor this and call it middleware and move it to client/ to avoid confusion with middleware in servers. I think this fits the common terminology better. Let me know if that works for you.

There is still a lint error around usage of console, which i believe existed in your version. I can take a look but if you get to it before, i'd much appreciate it. I think we are very close to accepting this.

@m-paternostro
Copy link
Contributor Author

m-paternostro commented Aug 8, 2025

@dsp-ant Great news!

Your changes look good! Some comments:

  • Do we need the deprecated types FetchWrapper and FetchMiddleware? I don't see other references to them on the project.
  • Should applyMiddleware be applyMiddlewares? You know the naming conventions more than I do.
  • The createMiddleware is very nice!

Regarding the lint errors, the offending code is related to providing logging for fetch operations, so probably not related to stdio. Would the use of console.log and console.error be OK or is there a better alternative? Btw, the error does not show up on shared where the code was initially located - the no console rule only applies to the files in client and server.

m-paternostro and others added 5 commits August 12, 2025 13:32
Add fetchWrapper utilities to allow customization of fetch behavior in MCP transports:
- withOAuth: handles authentication with automatic retry on 401
- withLogging: configurable HTTP request/response logging
- composeFetchWrappers: utility for combining multiple wrappers
- Rename FetchWrapper type to FetchMiddleware for clarity
- Rename withWrappers() to applyMiddleware() following standard middleware patterns
- Update parameter names from 'fetch' to 'next' aligning with middleware conventions
- Update all test names and variables to use middleware terminology
- Add deprecation aliases for backward compatibility

This change makes the middleware pattern more recognizable to developers familiar with standard middleware systems like Express, Redux, etc.
- Implement createMiddleware helper that provides cleaner syntax
- Separates next handler and request parameters for easier access
- Supports all middleware patterns: conditional logic, short-circuiting, response transformation
- Add comprehensive test coverage for all use cases
- Maintains full compatibility with existing middleware patterns

Example usage:
const customMiddleware = createMiddleware(async (next, input, init) => {
  const headers = new Headers(init?.headers);
  headers.set('X-Custom', 'value');
  return next(input, { ...init, headers });
});
- Renamed fetchWrapper.ts to middleware.ts for cleaner naming
- Renamed FetchMiddleware type to Middleware (kept deprecated aliases)
- Moved middleware files from src/shared/ to src/client/ since this is client-specific
- Updated all imports to reflect new location
- Fixed test for short-circuit responses

The middleware is now properly located in the client directory where it belongs,
separate from any server-side middleware.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
- Remove deprecated aliases: FetchWrapper and FetchMiddleware
- Rename applyMiddleware to applyMiddlewares
- Ignore lint errors because of `console` usage adding a warning to the JSDoc
@m-paternostro
Copy link
Contributor Author

m-paternostro commented Aug 12, 2025

@dsp-ant I just pushed a commit with changes related to my questions on the previous comment.

Please take a look specially on the usage of console.log and console.error - they are used by the default logger, which is automatically selected if the code invoking withLogging does not provider a logger.

@m-paternostro m-paternostro changed the title feature(auth): Allow delegating OAuth authorization to existing app-level implementations feature(middleware): Composable fetch middleware for auth and cross‑cutting concerns Aug 14, 2025
@m-paternostro
Copy link
Contributor Author

@pcarleton and @dsp-ant I've updated both the title and description of this PR to reflect the focus on providing a middleware programming model.

@pcarleton
Copy link
Member

this looks great! I'd like to try sticking it into the transports to replace the existing stuff before merging. the actual replacement can be a different PR but just want to make sure it works

@m-paternostro
Copy link
Contributor Author

m-paternostro commented Aug 14, 2025

this looks great!

Team work ;-)

just want to make sure it works

The unit tests are inspired by the authentication tests for the transports so I'd like to believe the basics are covered and, kudos to the design, the code is simple enough to pass the "eyes inspection" check. But I definitely don't want to claim this is fully tested, accounting for all OAuth nuances.

I'd like to try sticking it into the transports

The src/examples/client/simpleOAuthClient.ts is not working for me - I get an error when I run node dist/cjs/examples/client/simpleOAuthClient.js - and, unfortunately, I don't have time this week to debug the issue.

I am bringing this up because that example is probably a good way to test the changes here... We could use the withOAuth instead of passing the oauthProvider directly to the StreamableHTTPClientTransport on line 187.

@pcarleton
Copy link
Member

ahh great, thanks I'll start there. once i get that working, I want to see take a stab at what we'd rm from the transport files (e.g. i think things like _authThenStart will go away)

Copy link
Member

@pcarleton pcarleton left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

It worked as intended in the example client (I did need to swap out the finishAuth call for a direct call to auth).

Let's get this in, and then we can separately address migrating the transports to use it.

@m-paternostro
Copy link
Contributor Author

Let me know if there is anything I can do to help to have this merged. And even after that ;-)

@pcarleton pcarleton merged commit abb2f0a into modelcontextprotocol:main Aug 18, 2025
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants